經過前四天的準備工作(PRD、User Story、AC、UI/UX),今天我們終於要開始寫 code 了!
但不是直接寫功能,而是用 TDD(Test-Driven Development) 的方式:先寫測試,再寫實作。
很多人(包括以前的我)覺得 TDD 很麻煩:「為什麼要先寫測試?直接寫功能不是更快嗎?」
但經歷過幾次「改A壞B」的痛苦後,我才理解 TDD 的價值:
傳統開發:功能 → 測試 → 發現Bug → 修Bug → 又壞了別的地方
TDD 開發:測試 → 功能 → 通過測試 → 有保護網可以放心重構
今天的任務:
在開始之前,先理解 TDD 的核心流程。
🔴 Red(紅燈):寫一個失敗的測試
↓
🟢 Green(綠燈):寫最少的 code 讓測試通過
↓
♻️ Refactor(重構):優化 code 但保持測試通過
↓
重複循環
1. 紅燈階段:確保測試有用
2. 綠燈階段:快速驗證想法
3. 重構階段:提升代碼品質
在前三個專案的實踐中,我深刻體會到 TDD 的好處:
1. 早期發現問題
2. 有信心重構
3. 活文件
首先,建立一個全新的 Next.js 14 專案。
# 使用 create-next-app 建立專案
npx create-next-app@latest bolthq
# 選項:
# ✓ TypeScript
# ✓ ESLint
# ✓ Tailwind CSS
# ✓ src/ directory
# ✓ App Router
# × Turbopack
cd bolthq
# UI 套件
npm install @radix-ui/react-slot class-variance-authority clsx tailwind-merge lucide-react
# 狀態管理
npm install zustand @tanstack/react-query
# 表單處理
npm install react-hook-form zod @hookform/resolvers
# AI 整合
npm install @anthropic-ai/sdk
# 測試相關(開發相依)
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitejs/plugin-react
根據 Day 4 的設計,建立清晰的資料夾結構:
bolthq/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── globals.css
│ │
│ ├── components/ # UI 元件
│ │ ├── ui/ # 基礎元件
│ │ ├── forms/ # 表單元件
│ │ └── layouts/ # 版面元件
│ │
│ ├── lib/ # 工具函式
│ │ ├── utils.ts
│ │ └── design-tokens.ts
│ │
│ ├── services/ # 業務邏輯
│ │ ├── ai/
│ │ └── github/
│ │
│ ├── stores/ # Zustand 狀態管理
│ │
│ └── types/ # TypeScript 型別
│
├── tests/ # 測試檔案
│ ├── components/
│ ├── services/
│ └── setup.ts
│
├── docs/ # 文件
│ ├── PRD.md
│ ├── USER_STORIES.md
│ └── UI_UX_DESIGN.md
│
├── vitest.config.ts # Vitest 設定
└── package.json
為什麼這樣分類?
Next.js 14 預設沒有測試設定,我們要手動配置 Vitest。
創建 vitest.config.ts:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
globals: true,
css: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
創建 tests/setup.ts:
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
// 擴充 expect
expect.extend(matchers)
// 每個測試後清理
afterEach(() => {
cleanup()
})
{
\"scripts\": {
\"dev\": \"next dev\",
\"build\": \"next build\",
\"start\": \"next start\",
\"test\": \"vitest\",
\"test:ui\": \"vitest --ui\",
\"test:coverage\": \"vitest --coverage\"
}
}
先寫一個簡單的測試驗證環境:
// tests/example.test.ts
import { describe, it, expect } from 'vitest'
describe('Test Environment', () => {
it('should work', () => {
expect(1 + 1).toBe(2)
})
})
npm test
結果:
🟢 Test Files 1 passed (1)
🟢 Tests 1 passed (1)
完美!測試環境就緒了。
根據 Day 4 的設計,先建立 Design System 的基礎。
創建 src/lib/design-tokens.ts:
export const colors = {
primary: {
DEFAULT: '#2563EB',
hover: '#1D4ED8',
active: '#1E40AF',
light: '#DBEAFE',
dark: '#1E3A8A',
},
secondary: {
DEFAULT: '#7C3AED',
hover: '#6D28D9',
light: '#EDE9FE',
},
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#3B82F6',
gray: {
50: '#F9FAFB',
100: '#F3F4F6',
200: '#E5E7EB',
300: '#D1D5DB',
400: '#9CA3AF',
500: '#6B7280',
600: '#4B5563',
700: '#374151',
800: '#1F2937',
900: '#111827',
},
} as const
export const spacing = {
0: '0px',
1: '4px',
2: '8px',
3: '12px',
4: '16px',
5: '20px',
6: '24px',
8: '32px',
10: '40px',
12: '48px',
16: '64px',
} as const
export const borderRadius = {
sm: '4px',
md: '8px',
lg: '12px',
xl: '16px',
full: '9999px',
} as const
import type { Config } from 'tailwindcss'
import { colors, spacing, borderRadius } from './src/lib/design-tokens'
const config: Config = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors,
spacing,
borderRadius,
fontFamily: {
sans: ['Inter', 'Noto Sans TC', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
}
export default config
根據 Day 4 的設計,建立 Button 元件。
先創建工具函式 src/lib/utils.ts:
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
然後創建 src/components/ui/button.tsx:
import { ButtonHTMLAttributes, forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-white hover:bg-primary-hover active:scale-[0.98]',
secondary: 'border-2 border-primary text-primary hover:bg-primary-light',
ghost: 'text-gray-600 hover:bg-gray-100',
},
size: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4',
lg: 'h-12 px-6 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
現在環境都準備好了,開始第一個 TDD 循環!
根據 Day 3 的 User Story #1,我們要實作 Spec Input 頁面的第一個 AC。
Given 我在首頁
When 我點擊「開始新專案」
Then 系統顯示一個大型文字輸入框
And 有提示文字:「用一段話描述你想做的產品」
And 有範例可以參考
創建 tests/components/project-input.test.tsx:
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ProjectInput } from '@/components/forms/project-input'
describe('ProjectInput Component', () => {
describe('AC 1.1: 基本輸入介面', () => {
it('應該顯示一個大型文字輸入框', () => {
render(<ProjectInput />)
const textarea = screen.getByRole('textbox')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveClass('min-h-[200px]')
})
it('應該顯示提示文字', () => {
render(<ProjectInput />)
const placeholder = screen.getByPlaceholderText(/用一段話描述/i)
expect(placeholder).toBeInTheDocument()
})
it('應該有範例連結', () => {
render(<ProjectInput />)
const exampleLink = screen.getByText(/查看範例/i)
expect(exampleLink).toBeInTheDocument()
})
})
})
執行測試:
npm test
結果:
🔴 FAIL tests/components/project-input.test.tsx
✗ Error: Cannot find module '@/components/forms/project-input'
完美!這正是我們要的 紅燈。
現在寫最少的 code 讓測試通過。
創建 src/components/forms/project-input.tsx:
import { Button } from '@/components/ui/button'
export function ProjectInput() {
return (
<div className=\"w-full max-w-2xl mx-auto p-6\">
<div className=\"mb-6\">
<label htmlFor=\"project-name\" className=\"block text-sm font-medium mb-2\">
專案名稱
</label>
<input
type=\"text\"
id=\"project-name\"
className=\"w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary\"
/>
</div>
<div className=\"mb-6\">
<label htmlFor=\"project-description\" className=\"block text-sm font-medium mb-2\">
描述你想做的產品
</label>
<textarea
id=\"project-description\"
className=\"w-full min-h-[200px] px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary resize-y\"
placeholder=\"用一段話描述你想做的產品…\"
/>
<p className=\"mt-2 text-sm text-gray-500\">
💡 提示:至少 20 字,越詳細越好
</p>
</div>
<div className=\"flex items-center justify-between\">
<button className=\"text-primary hover:underline\">
查看範例
</button>
<Button>
開始分析 →
</Button>
</div>
</div>
)
}
執行測試:
npm test
結果:
🟢 PASS tests/components/project-input.test.tsx
✓ 應該顯示一個大型文字輸入框
✓ 應該顯示提示文字
✓ 應該有範例連結
🟢 Tests 3 passed (3)
太好了!綠燈!
現在測試通過了,可以優化 code:
import { useState } from 'react'
import { Button } from '@/components/ui/button'
interface ProjectInputProps {
onSubmit?: (data: { name: string; description: string }) => void
onShowExample?: () => void
}
export function ProjectInput({ onSubmit, onShowExample }: ProjectInputProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const charCount = description.length
const minChars = 20
const isValid = charCount >= minChars
const handleSubmit = () => {
if (isValid && onSubmit) {
onSubmit({ name, description })
}
}
return (
<div className=\"w-full max-w-2xl mx-auto p-6\">
<div className=\"mb-6\">
<label
htmlFor=\"project-name\"
className=\"block text-sm font-medium text-gray-700 mb-2\"
>
專案名稱
</label>
<input
type=\"text\"
id=\"project-name\"
value={name}
onChange={(e) => setName(e.target.value)}
className=\"w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary transition-all\"
placeholder=\"給你的專案取個名字…\"
/>
</div>
<div className=\"mb-6\">
<label
htmlFor=\"project-description\"
className=\"block text-sm font-medium text-gray-700 mb-2\"
>
描述你想做的產品
</label>
<textarea
id=\"project-description\"
value={description}
onChange={(e) => setDescription(e.target.value)}
className=\"w-full min-h-[200px] px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary resize-y transition-all\"
placeholder=\"用一段話描述你想做的產品…\"
/>
<div className=\"mt-2 flex items-center justify-between\">
<p className=\"text-sm text-gray-500\">
💡 提示:至少 {minChars} 字,越詳細越好
</p>
<p className={`text-sm ${
charCount < minChars ? 'text-gray-400' : 'text-green-600'
}`}>
{charCount} / {minChars}
</p>
</div>
</div>
<div className=\"flex items-center justify-between\">
<button
onClick={onShowExample}
className=\"text-primary hover:underline transition-all\"
>
查看範例
</button>
<Button
onClick={handleSubmit}
disabled={!isValid}
>
開始分析 →
</Button>
</div>
</div>
)
}
再次執行測試:
npm test
結果:
🟢 PASS tests/components/project-input.test.tsx
✓ 應該顯示一個大型文字輸入框
✓ 應該顯示提示文字
✓ 應該有範例連結
完美!重構後測試仍然通過。
現在我們已經完成了 AC 1.1,繼續 AC 1.2。
Given 我在輸入框中
When 我輸入少於 20 字的描述
Then 系統顯示警告:「請至少描述 20 字以上」
And 提交按鈕保持禁用狀態
import { userEvent } from '@testing-library/user-event'
describe('AC 1.2: 輸入驗證', () => {
it('輸入少於 20 字應該禁用按鈕', async () => {
const user = userEvent.setup()
render(<ProjectInput />)
const textarea = screen.getByRole('textbox', { name: /描述/i })
await user.type(textarea, 'short text')
const button = screen.getByRole('button', { name: /開始分析/i })
expect(button).toBeDisabled()
})
it('輸入超過 20 字應該啟用按鈕', async () => {
const user = userEvent.setup()
render(<ProjectInput />)
const textarea = screen.getByRole('textbox', { name: /描述/i })
await user.type(textarea, '這是一段超過 20 字的描述內容,用來測試按鈕是否啟用')
const button = screen.getByRole('button', { name: /開始分析/i })
expect(button).toBeEnabled()
})
it('應該顯示字數統計', async () => {
const user = userEvent.setup()
render(<ProjectInput />)
const textarea = screen.getByRole('textbox', { name: /描述/i })
await user.type(textarea, '測試')
expect(screen.getByText(/2 \\/ 20/)).toBeInTheDocument()
})
})
執行測試:
npm test
結果:
🟢 PASS AC 1.1 (3 tests)
🟢 PASS AC 1.2 (3 tests)
🟢 Tests 6 passed (6)
太好了!我們剛才重構時已經實作了這些功能,所以測試直接通過。
這就是 TDD 的魅力!
✅ 建立 Next.js 14 專案
✅ 設定 Vitest 測試環境
✅ 建立 Design System 基礎
✅ 實作第一個元件:ProjectInput
✅ 完成 6 個測試案例(AC 1.1 & 1.2)
✅ 體驗完整的 TDD 紅綠燈循環
更重要的是,我們現在有:
全部都是 SDD AI Sprint 前面已經有規格的情況下產出程式碼,效率非常非常高~